iOS 冷启动优化

2020-03-08

背景

冷启动时长是App性能的重要指标, 随着功能迭代, 项目越来越庞大, 冷启动时间也越来越长. 本文将为大家介绍项目里冷启动优化遇到的问题、方案和收获.

冷启动定义: app第一次启动的过程(或者app被kill后, 重新启动的过程)

一般而言, 大家把iOS冷启动的过程定义为: 从用户点击App图标开始到appDelegate didFinishLaunching方法执行完成为止. 这个过程主要分为两个阶段:
1. pre-main阶段大致做了如下事情:
1.1 加载应用的可执行文件(比如Mach-o文件)
1.2 加载动态链接库加载器dyld(dynamic loader)
1.3 dyld递归加载应用所有依赖的dylib(dynamic library 动态链接库), 包括iOS系
统的以及APP依赖的第三方库
1.4 dyld调用main()

图片

2. main阶段:
2.1 调用UIApplicationMain()
2.2 调用applicationWillFinishLaunching(一般用不到)
2.3 调用didFinishLaunchingWithOptions
2.4 从AppDelegate的didFinishLaunch到第一个页面的viewDidAppear(当didFinishLaunchingWithOptions执行完成时,用户还没有看到App的主界面, 用户真正看到数据内容并开始使用,我们认为这个时候冷启动才算完成)

App冷启动现状分析

图片

三. 现存问题
图片
在App早期阶段, 冷启动不会有明显的性能问题. 冷启动性能问题也不是在某个版本突然出现的, 随着版本迭代, App功能越来越复杂, 启动任务越来越多, 冷启动时间也一点点延长. App的冷启动性能问题增量主要来自冷启动阶段管理混乱和启动项的增加, 随着版本迭代和各个业务方的需求, 启动项任务简单粗暴地堆积在启动流程中.

四. 解决思路

1. 冷启动阶段管理:
现状: 之前LoadService的思路是在plist文件中设置类名列表, +load方法中预埋这些类以及对应的时机, 冷启动到一定阶段触发对应的方法, 这种方案的缺点在于I/O操作和初始化实例变量比较频繁. 在整理过程中还发现有嵌套现象发生, 某个实例在执行冷启动某个时机方法中触发了另一个时机.
解决方案: 使用Arthur(公司自研插桩工具)替换原有LoadService管理APP冷启动阶段方式, 梳理每个场景的触发时机是否合理, 统一收归冷启动所有触发的场景, 禁止嵌套行为, 尽量保证主线程中顺序执行的逻辑清晰.
Arthur介绍:
之前说过, 冷启动pre_main阶段中有加载可执行文件, 最常见的是Mach-O文件类型, 简单来讲就是Clang编译器, 编译出的一个产物.
当App启动时, 会将Mach-O加载到内存中(其实内存中是对该Mach-O文件的映射). 它只是二进制字节流, 里面有不同的包含元信息的数据块, 比如字节顺序, cpu 类型, 块大小等. 文件内容是不可以修改的, 因为在 .app 目录中有个 _CodeSignature 的目录, 里面包含了程序代码的签名, 这个签名的作用就是保证签名后 .app 里的文件, 包括资源文件, Mach-O 文件都不能更改.
Mach-O 的内容:

  • Mach-O Header: 包含字节顺序, magic, cpu 类型, 加载指令的数量等
  • Load Commands: 包含很多内容的表, 包括区域的位置, 符号表, 动态符号表等. 每个加载指令包含一个元信息, 比如指令类型, 名称, 在二进制中的位置等
  • 原始段数据(Raw segment data): 可以拥有多个段(segment), 每个段可以拥有零个或多个区域(section). 每一个段(segment)都拥有一段虚拟地址映射到进程

图片

编译器提供了我们一种__attribute__((section(“xxx段,xxx节”)的方式让我们将一个指定的数据储存到我们需要的节当中.
ArThur的核心思想就是在编译时把数据(如函数指针)写入到可执行文件的__DATA段中, 运行时再从__DATA段取出数据进行相应的操作(调用函数)
核心代码:
① 宏定义:

1
2
#define _AT_DATA_DEFINE(HEADER, VALUE)   \
__attribute__((used, section(AT_SEGMENT_SECTION))) static const AT_DATA AT_UNIQUE_IDENTIFIER = (AT_DATA){HEADER, VALUE}

② 读取函数地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+ (void)readSectionWithName:(char *)sectionName fromAllImageWithStep:(size_t)step usingBlock:(nonnull ATImageReadingBlock)block {
uint32_t count = _dyld_image_count();
for (uint32_t i = 0; i < count; i++) {
const struct mach_header* image_header = _dyld_get_image_header(i);
Dl_info info;
if (dladdr(image_header, &info) == 0) {
continue;
}
// Segment Name
const void *mach_header = info.dli_fbase;
const ATSection *section = at_getSectByNameFromHeader((void *)mach_header, segmentName, sectionName);
if (section == NULL) return;

for (ATValue offset = section->offset; offset < section->offset + section->size; offset += step) {
block((const void **)(mach_header + offset));
}
}
}

2. +load治理:
通过插桩的方式获取+load方法耗时, 排查每个+load的方法是否合理, 对于不合理的可以延迟调用时机或者移除. 可以使用工具A4LoadMeasure实现打印所有和每个+load方法耗时.

图片

3. 移除不需要的逻辑和冗余代码.
4. 之前路由和其他基础类使用plist文件的形式加载数据源, 替换加载方式减少I/O操作.
5. 冷启动阶段管理:
基于Arthur重新定义冷启动阶段, 梳理每个冷启动阶段需要执行的启动项任务, 规范初始化SDK、全局设置以及业务方调用的接口等行为. 这个过程比较繁琐, 一是之前阶段比较混乱, 二是业务逻辑比较多, 要和各个业务方对接, 确定合理的调用时机.
6. 对冷启动时长和每个阶段进行监控, 方便排查异常情况.

五. 其他优化手段之二进制重排

进程如果能直接访问物理内存无疑是很不安全的, 所以操作系统在物理内存的上又建立了一层虚拟内存. 为了提高效率和方便管理, 又对虚拟内存和物理内存又进行分页(Page). 当进程访问一个虚拟内存Page而对应的物理内存却不存在时, 会触发一次缺页中断(Page Fault), 分配物理内存, 有需要的话会从磁盘mmap读人数据.
通过App Store渠道分发的App, Page Fault还会进行签名验证, 所以一次Page Fault的耗时比想象的要多:

图片

编译器在生成二进制代码的时候, 默认按照链接的Object File(.o)顺序写文件, 按照Object File内部的函数顺序写函数. 静态库文件.a就是一组.o文件的ar包, 可以用ar -t查看.a包含的所有.o
假设我们只有两个page:page1/page2, 其中绿色的method1和method3启动时候需要调用, 为了执行对应的代码, 系统必须进行两个Page Fault.
图片
但如果我们把method1和method3排布到一起, 那么只需要一个Page Fault即可, 这就是二进制文件重排的核心原理.
图片
这里推荐一个工具AppOrderFiles, 本质上通过插桩记录调用的函数, 并生成order文件. 将order文件配置到主工程下, 并在xcode中指定对应路径即可.
可以查看LinkMap内容确定当前的函数布局, 验证重排是否成功
使用System Trace验证重排的效果, 对于项目重排前而言, page in的次数4000多次:

图片

重排后page in的次数为900多次, debug下的时间不是特别准确, 但也可以粗略看到效果

图片

六. 总结

治理前项目的冷启动均值1600ms, 治理后可以达到900ms以内. 其中某一个版本将Arthur管理冷启动阶段和二进制重排接入项目上线数据稳定后, 冷启动减少了300ms的时间. 治理的过程中嵌入了监控的逻辑, 在优化过程中需要验证每个方案的可行性, 对于一些异常数据的分析和问题排查也很有意思. 冷启动流程也是一个比较复杂的过程, 我们可以根据App自身的特点, 配合工具的使用, 从多方面、多角度进行优化. 启动的优化也不是一次性的, 后期维护也同样重要.